/**
 * @license
 * Copyright 2016 Google Inc.
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import type {
  CoordinateSpace,
  DimensionId,
} from "#src/coordinate_transform.js";
import {
  clampAndRoundCoordinateToVoxelCenter,
  dimensionNamesFromJson,
  emptyInvalidCoordinateSpace,
  getBoundingBoxCenter,
  getCenterBound,
} from "#src/coordinate_transform.js";
import type { WatchableValueInterface } from "#src/trackable_value.js";
import { arraysEqual } from "#src/util/array.js";
import type { Borrowed, Owned } from "#src/util/disposable.js";
import { RefCounted } from "#src/util/disposable.js";
import { mat3, mat4, quat, vec3 } from "#src/util/geom.js";
import {
  parseArray,
  parseFiniteVec,
  verifyBoolean,
  verifyEnumString,
  verifyFiniteFloat,
  verifyFinitePositiveFloat,
  verifyObject,
  verifyObjectProperty,
  verifyOptionalObjectProperty,
} from "#src/util/json.js";
import { NullarySignal } from "#src/util/signal.js";
import type { Trackable } from "#src/util/trackable.js";
import { optionallyRestoreFromJsonMember } from "#src/util/trackable.js";
import { TrackableEnum } from "#src/util/trackable_enum.js";
import * as vector from "#src/util/vector.js";

export enum NavigationLinkType {
  LINKED = 0,
  RELATIVE = 1,
  UNLINKED = 2,
}

export enum NavigationSimpleLinkType {
  LINKED = 0,
  UNLINKED = 2,
}

export class TrackableNavigationLink extends TrackableEnum<NavigationLinkType> {
  constructor(value = NavigationLinkType.LINKED) {
    super(NavigationLinkType, value);
  }
}

export class TrackableNavigationSimpleLink extends TrackableEnum<NavigationSimpleLinkType> {
  constructor(value = NavigationSimpleLinkType.LINKED) {
    super(NavigationSimpleLinkType, value);
  }
}

const tempVec3 = vec3.create();
const tempQuat = quat.create();

function makeLinked<
  T extends RefCounted & { changed: NullarySignal },
  Difference,
>(
  self: T,
  peer: T,
  link: TrackableNavigationLink,
  operations: {
    assign: (target: T, source: T) => void;
    isValid: (a: T) => boolean;
    difference: (a: T, b: T) => Difference;
    add: (target: T, source: T, amount: Difference) => void;
    subtract: (target: T, source: T, amount: Difference) => void;
  },
): T {
  let updatingSelf = false;
  const updatingPeer = false;
  let selfMinusPeer: Difference | undefined;
  self.registerDisposer(peer);
  const handlePeerUpdate = () => {
    if (updatingPeer) {
      return;
    }
    updatingSelf = true;
    switch (link.value) {
      case NavigationLinkType.UNLINKED:
        if (operations.isValid(self)) {
          break;
        }
      // fallthrough
      case NavigationLinkType.LINKED:
        operations.assign(self, peer);
        break;
      case NavigationLinkType.RELATIVE:
        operations.add(self, peer, selfMinusPeer!);
        break;
    }
    updatingSelf = false;
  };
  const handleSelfUpdate = () => {
    if (updatingSelf) {
      return;
    }
    switch (link.value) {
      case NavigationLinkType.UNLINKED:
        break;
      case NavigationLinkType.LINKED:
        operations.assign(peer, self);
        break;
      case NavigationLinkType.RELATIVE:
        operations.subtract(peer, self, selfMinusPeer!);
        break;
    }
  };
  let previousLinkValue = NavigationLinkType.UNLINKED;
  const handleLinkUpdate = () => {
    const linkValue = link.value;
    if (linkValue !== previousLinkValue) {
      switch (linkValue) {
        case NavigationLinkType.UNLINKED:
          selfMinusPeer = undefined;
          break;
        case NavigationLinkType.LINKED:
          selfMinusPeer = undefined;
          operations.assign(self, peer);
          break;
        case NavigationLinkType.RELATIVE:
          selfMinusPeer = operations.difference(self, peer);
          break;
      }
    }
    previousLinkValue = linkValue;
    self.changed.dispatch();
  };
  self.registerDisposer(self.changed.add(handleSelfUpdate));
  self.registerDisposer(peer.changed.add(handlePeerUpdate));
  self.registerDisposer(link.changed.add(handleLinkUpdate));
  handleLinkUpdate();
  return self;
}

function makeSimpleLinked<T extends RefCounted & { changed: NullarySignal }>(
  self: T,
  peer: T,
  link: TrackableNavigationSimpleLink,
  operations: {
    assign: (target: T, source: T) => void;
    isValid: (a: T) => boolean;
  },
) {
  return makeLinked(self, peer, link as any, operations as any);
}

export class Position extends RefCounted {
  private coordinates_: Float32Array = vector.kEmptyFloat32Vec;
  private curCoordinateSpace: CoordinateSpace | undefined;
  changed = new NullarySignal();
  constructor(
    public coordinateSpace: WatchableValueInterface<CoordinateSpace>,
  ) {
    super();
    this.registerDisposer(
      coordinateSpace.changed.add(() => {
        this.handleCoordinateSpaceChanged();
      }),
    );
  }

  get valid() {
    return this.coordinateSpace.value.valid;
  }

  /**
   * Returns the position in voxels.
   */
  get value() {
    this.handleCoordinateSpaceChanged();
    return this.coordinates_;
  }

  reset() {
    this.curCoordinateSpace = undefined;
    this.coordinates_ = vector.kEmptyFloat32Vec;
    this.changed.dispatch();
  }

  set value(coordinates: Float32Array) {
    const { curCoordinateSpace } = this;
    if (
      curCoordinateSpace === undefined ||
      !curCoordinateSpace.valid ||
      curCoordinateSpace.rank !== coordinates.length
    ) {
      return;
    }
    const { coordinates_ } = this;
    coordinates_.set(coordinates);
    this.changed.dispatch();
  }

  private handleCoordinateSpaceChanged() {
    const coordinateSpace = this.coordinateSpace.value;
    const prevCoordinateSpace = this.curCoordinateSpace;
    if (coordinateSpace === prevCoordinateSpace) return;
    this.curCoordinateSpace = coordinateSpace;
    const { rank } = coordinateSpace;
    if (!coordinateSpace.valid) return;
    if (prevCoordinateSpace === undefined || !prevCoordinateSpace.valid) {
      let { coordinates_ } = this;
      if (coordinates_ !== undefined && coordinates_.length === rank) {
        // Use the existing voxel coordinates if rank is the same.  Otherwise, ignore.
      } else {
        coordinates_ = this.coordinates_ = new Float32Array(rank);
        getBoundingBoxCenter(coordinates_, coordinateSpace.bounds);
        const { voxelCenterAtIntegerCoordinates } = coordinateSpace.bounds;
        for (let i = 0; i < rank; ++i) {
          if (voxelCenterAtIntegerCoordinates[i]) {
            coordinates_[i] = Math.round(coordinates_[i]);
          } else {
            coordinates_[i] = Math.floor(coordinates_[i]) + 0.5;
          }
        }
      }
      this.changed.dispatch();
      return;
    }
    // Match dimensions by ID.
    const newCoordinates = new Float32Array(rank);
    const prevCoordinates = this.coordinates_;
    const { ids, scales: newScales } = coordinateSpace;
    const { ids: prevDimensionIds, scales: oldScales } = prevCoordinateSpace;
    for (let newDim = 0; newDim < rank; ++newDim) {
      const newDimId = ids[newDim];
      const oldDim = prevDimensionIds.indexOf(newDimId);
      if (oldDim === -1) {
        newCoordinates[newDim] = getCenterBound(
          coordinateSpace.bounds.lowerBounds[newDim],
          coordinateSpace.bounds.upperBounds[newDim],
        );
      } else {
        newCoordinates[newDim] =
          prevCoordinates[oldDim] * (oldScales[oldDim] / newScales[newDim]);
      }
    }
    this.coordinates_ = newCoordinates;
    this.changed.dispatch();
  }

  toJSON() {
    if (!this.valid && this.coordinates_.length === 0) return undefined;
    this.handleCoordinateSpaceChanged();
    const { value } = this;
    if (value.length === 0) return undefined;
    return Array.from(value);
  }

  restoreState(obj: any) {
    if (obj === undefined) {
      this.reset();
      return;
    }
    this.curCoordinateSpace = undefined;
    this.coordinates_ = Float32Array.from(parseArray(obj, verifyFiniteFloat));
    this.handleCoordinateSpaceChanged();
    this.changed.dispatch();
  }

  snapToVoxel() {
    this.handleCoordinateSpaceChanged();
    const {
      bounds: { voxelCenterAtIntegerCoordinates },
    } = this.coordinateSpace.value;
    const { coordinates_ } = this;
    const rank = coordinates_.length;
    for (let i = 0; i < rank; ++i) {
      if (voxelCenterAtIntegerCoordinates[i]) {
        coordinates_[i] = Math.round(coordinates_[i]);
      } else {
        coordinates_[i] = Math.floor(coordinates_[i]) + 0.5;
      }
    }
    this.changed.dispatch();
  }

  assign(other: Borrowed<Position>) {
    other.handleCoordinateSpaceChanged();
    const { curCoordinateSpace, coordinates_ } = other;
    this.curCoordinateSpace = curCoordinateSpace;
    this.coordinates_ = Float32Array.from(coordinates_);
    this.changed.dispatch();
  }

  /**
   * Get the offset of `a` relative to `b`.
   */
  static getOffset(a: Position, b: Position): Float32Array | undefined {
    const aCoordinates = a.coordinates_;
    const bCoordinates = b.coordinates_;
    const rank = aCoordinates.length;
    if (rank === bCoordinates.length) {
      return vector.subtract(
        new Float32Array(aCoordinates.length),
        aCoordinates,
        bCoordinates,
      );
    }
    return undefined;
  }
  static addOffset(
    target: Position,
    source: Position,
    offset: Float32Array | undefined,
    scale = 1,
  ): void {
    target.handleCoordinateSpaceChanged();
    const { value: sourceCoordinates } = source;
    if (offset !== undefined && sourceCoordinates.length === offset.length) {
      vector.scaleAndAdd(target.value, sourceCoordinates, offset, scale);
      target.changed.dispatch();
    }
  }

  get legacyJsonView() {
    const self = this;
    return {
      changed: self.changed,
      toJSON() {
        return self.toJSON();
      },
      reset() {
        self.reset();
      },
      restoreState(obj: unknown) {
        if (obj === undefined || Array.isArray(obj)) {
          self.restoreState(obj);
          return;
        }
        verifyObject(obj);
        optionallyRestoreFromJsonMember(obj, "voxelCoordinates", self);
      },
    };
  }
}

export enum VelocityBoundaryBehavior {
  STOP = 0,
  LOOP = 1,
  REVERSE = 2,
}

export const DEFAULT_PLAYBACK_VELOCITY = 10;

export class DimensionPlaybackVelocity {
  // Velocity in global coordinates per second.
  velocity: number = DEFAULT_PLAYBACK_VELOCITY;
  atBoundary: VelocityBoundaryBehavior = VelocityBoundaryBehavior.REVERSE;
  paused = true;
}

export function dimensionVelocitiesEqual(
  a: DimensionPlaybackVelocity,
  b: DimensionPlaybackVelocity,
): boolean {
  return (
    a.velocity === b.velocity &&
    a.atBoundary === b.atBoundary &&
    a.paused === b.paused
  );
}

function dimensionVelocityFromJson(obj: unknown) {
  verifyObject(obj);
  return {
    velocity: verifyOptionalObjectProperty(
      obj,
      "velocity",
      verifyFiniteFloat,
      DEFAULT_PLAYBACK_VELOCITY,
    ),
    atBoundary: verifyOptionalObjectProperty(
      obj,
      "atBoundary",
      (value) => verifyEnumString(value, VelocityBoundaryBehavior),
      VelocityBoundaryBehavior.STOP,
    ),
    paused: verifyOptionalObjectProperty(obj, "paused", verifyBoolean, true),
  };
}

function dimensionVelocityToJson(info: DimensionPlaybackVelocity) {
  const { velocity, atBoundary, paused } = info;
  return {
    velocity: velocity,
    atBoundary:
      atBoundary === VelocityBoundaryBehavior.STOP
        ? undefined
        : VelocityBoundaryBehavior[atBoundary].toLowerCase(),
    paused: paused ? undefined : false,
  };
}

export class CoordinateSpacePlaybackVelocity extends RefCounted {
  private velocities_: (Readonly<DimensionPlaybackVelocity> | undefined)[];
  private curCoordinateSpace: CoordinateSpace | undefined;
  changed = new NullarySignal();
  constructor(
    public coordinateSpace: WatchableValueInterface<CoordinateSpace>,
  ) {
    super();
    this.registerDisposer(
      coordinateSpace.changed.add(() => {
        this.handleCoordinateSpaceChanged();
      }),
    );
    this.curCoordinateSpace = coordinateSpace.value;
    this.velocities_ = new Array(this.curCoordinateSpace?.rank ?? 0);
  }

  get valid() {
    return this.coordinateSpace.value.valid;
  }

  get value(): (Readonly<DimensionPlaybackVelocity> | undefined)[] {
    this.handleCoordinateSpaceChanged();
    return this.velocities_;
  }

  set value(velocities: (Readonly<DimensionPlaybackVelocity> | undefined)[]) {
    const { curCoordinateSpace } = this;
    if (
      curCoordinateSpace === undefined ||
      curCoordinateSpace.rank !== velocities.length
    ) {
      return;
    }
    this.velocities_ = velocities;
    this.changed.dispatch();
  }

  get(id: DimensionId): DimensionPlaybackVelocity | undefined {
    const ids = this.coordinateSpace.value?.ids;
    if (ids === undefined) return;
    const index = ids.indexOf(id);
    if (index === -1) return;
    const velocities = this.value;
    return velocities[index];
  }

  dimensionVelocity(
    owner: RefCounted,
    id: DimensionId,
  ): WatchableValueInterface<DimensionPlaybackVelocity | undefined> {
    const changed = new NullarySignal();
    let index = -1;
    const updateIndex = () => {
      const ids = this.coordinateSpace.value?.ids;
      if (ids === undefined) {
        index = -1;
      } else if (index === -1 || ids[index] !== id) {
        index = ids.indexOf(id);
      }
    };
    const getVelocity = () => {
      updateIndex();
      if (index === -1) return undefined;
      return this.value[index];
    };
    const setVelocity = (
      newVelocity: Readonly<DimensionPlaybackVelocity> | undefined,
    ) => {
      updateIndex();
      if (index === -1) return;
      const velocities = this.value;
      const oldVelocity = velocities[index];
      if (oldVelocity === newVelocity) return;
      velocities[index] = newVelocity;
      this.changed.dispatch();
    };
    const prevVelocity = getVelocity();
    owner.registerDisposer(
      this.changed.add(() => {
        const curVelocity = getVelocity();
        if (curVelocity !== prevVelocity) {
          changed.dispatch();
        }
      }),
    );
    return {
      get value() {
        return getVelocity();
      },
      set value(newVelocity: Readonly<DimensionPlaybackVelocity> | undefined) {
        setVelocity(newVelocity);
      },
      changed,
    };
  }

  modifyDimension(
    id: DimensionId,
    callback: (
      oldInfo: DimensionPlaybackVelocity | undefined,
    ) => DimensionPlaybackVelocity | undefined,
  ) {
    const ids = this.coordinateSpace.value?.ids;
    if (ids === undefined) return;
    const index = ids.indexOf(id);
    if (index === -1) return;
    const velocities = this.value;
    const oldInfo = velocities[index];
    const newInfo = callback(oldInfo);
    if (oldInfo === newInfo) return;
    velocities[index] = newInfo;
    this.changed.dispatch();
  }

  togglePlayback(id: DimensionId, newValue: boolean | undefined = undefined) {
    this.modifyDimension(id, (oldInfo = new DimensionPlaybackVelocity()) => {
      return { ...oldInfo, paused: newValue ?? !oldInfo.paused };
    });
  }

  playbackEnabled(id: DimensionId): WatchableValueInterface<boolean> {
    const self = this;
    return {
      changed: this.changed,
      get value() {
        return self.get(id) !== undefined;
      },
      set value(enabled: boolean) {
        self.modifyDimension(id, (oldInfo) =>
          enabled ? (oldInfo ?? new DimensionPlaybackVelocity()) : undefined,
        );
      },
    };
  }

  multiplyVelocity(id: DimensionId, factor: number) {
    this.modifyDimension(id, (oldInfo = new DimensionPlaybackVelocity()) => {
      let newVelocity = Math.round(oldInfo.velocity * factor);
      if (newVelocity === 0) {
        newVelocity = Math.sign(oldInfo.velocity) || 1;
      }
      return { ...oldInfo, velocity: newVelocity };
    });
  }

  private handleCoordinateSpaceChanged() {
    const coordinateSpace = this.coordinateSpace.value;
    const prevCoordinateSpace = this.curCoordinateSpace;
    if (coordinateSpace === prevCoordinateSpace) return;
    this.curCoordinateSpace = coordinateSpace;
    const { rank } = coordinateSpace;
    if (!coordinateSpace.valid) return;
    if (prevCoordinateSpace === undefined) {
      let { velocities_ } = this;
      if (velocities_.length === rank) {
        // Use the existing velocities if rank is the same.  Otherwise, ignore.
      } else {
        velocities_ = new Array<
          Readonly<DimensionPlaybackVelocity> | undefined
        >(rank);
      }
      this.changed.dispatch();
      return;
    }
    // Match dimensions by ID.
    const newVelocities = new Array<
      Readonly<DimensionPlaybackVelocity> | undefined
    >(rank);
    const prevVelocities = this.velocities_;
    const { ids } = coordinateSpace;
    const { ids: prevDimensionIds } = prevCoordinateSpace;
    for (let newDim = 0; newDim < rank; ++newDim) {
      const newDimId = ids[newDim];
      const oldDim = prevDimensionIds.indexOf(newDimId);
      if (oldDim !== -1) {
        newVelocities[newDim] = prevVelocities[oldDim];
      }
    }
    this.velocities_ = newVelocities;
    this.changed.dispatch();
  }

  toJSON() {
    this.handleCoordinateSpaceChanged();
    const { velocities_: velocities, curCoordinateSpace } = this;
    if (
      !curCoordinateSpace?.valid ||
      !velocities.some((velocity) => velocity !== undefined)
    ) {
      return undefined;
    }
    const obj: Record<string, any> = {};
    const { names, rank } = curCoordinateSpace;
    for (let i = 0; i < rank; ++i) {
      const info = velocities[i];
      if (info === undefined) continue;
      obj[names[i]] = dimensionVelocityToJson(info);
    }
    return obj;
  }

  reset() {
    this.handleCoordinateSpaceChanged();
    this.velocities_ = new Array(this.curCoordinateSpace?.rank ?? 0);
  }

  restoreState(obj: any) {
    if (obj === undefined) {
      this.reset();
      return;
    }
    verifyObject(obj);
    const curCoordinateSpace = (this.curCoordinateSpace =
      this.coordinateSpace.value);
    this.velocities_ = new Array(curCoordinateSpace?.rank ?? 0);
    if (curCoordinateSpace === undefined) {
      throw new Error("Must specify dimensions in order to specify velocities");
    }
    const velocities = (this.velocities_ = new Array(
      curCoordinateSpace?.rank ?? 0,
    ));
    const { names } = curCoordinateSpace;
    for (const key of Object.keys(obj)) {
      const i = names.indexOf(key);
      if (i === -1) {
        throw new Error(`Invalid dimension name: ${JSON.stringify(key)}`);
      }
      velocities[i] = verifyObjectProperty(obj, key, dimensionVelocityFromJson);
    }
    this.changed.dispatch();
  }

  assign(other: Borrowed<CoordinateSpacePlaybackVelocity>) {
    const otherVelocities = other.value;
    const velocities = this.value;
    const rank = velocities.length;
    let changed = false;
    for (let i = 0; i < rank; ++i) {
      const newVelocity = otherVelocities[i];
      const curVelocity = velocities[i];
      if (newVelocity !== curVelocity) {
        if (
          curVelocity === undefined ||
          newVelocity === undefined ||
          !dimensionVelocitiesEqual(curVelocity, newVelocity)
        ) {
          changed = true;
        }
        velocities[i] = newVelocity;
      }
    }
    if (changed) {
      this.changed.dispatch();
    }
  }
}

export class LinkedCoordinateSpacePlaybackVelocity extends RefCounted {
  changed = new NullarySignal();
  velocity = this.registerDisposer(
    new CoordinateSpacePlaybackVelocity(this.peer.coordinateSpace),
  );

  constructor(
    public peer: Owned<CoordinateSpacePlaybackVelocity>,
    public positionLink: TrackableLinkInterface,
  ) {
    super();
    this.registerDisposer(peer);
    this.velocity.changed.add(() => {
      if (this.positionLink.value === NavigationLinkType.UNLINKED) {
        this.changed.dispatch();
      } else {
        this.peer.assign(this.velocity);
      }
    });
    const updateSelf = () => {
      if (this.positionLink.value !== NavigationLinkType.UNLINKED) {
        this.velocity.assign(this.peer);
      }
    };
    this.registerDisposer(peer.changed.add(updateSelf));
    updateSelf();
  }

  toJSON() {
    if (this.positionLink.value !== NavigationLinkType.UNLINKED) {
      return undefined;
    }
    return this.velocity.toJSON();
  }

  reset() {
    if (this.positionLink.value === NavigationLinkType.UNLINKED) {
      this.velocity.reset();
    }
  }

  restoreState(obj: unknown) {
    if (this.positionLink.value === NavigationLinkType.UNLINKED) {
      this.velocity.restoreState(obj);
    }
  }

  copyToPeer() {
    if (this.positionLink.value === NavigationLinkType.UNLINKED) {
      this.peer.assign(this.velocity);
    }
  }
}

interface DimensionPlaybackState {
  dimensionIndex: number;
  prevCoordinate: number;
  prevTime: number;
  generation: number;
}

export class PlaybackManager extends RefCounted {
  private dimensionStates = new Map<DimensionId, DimensionPlaybackState>();
  private lastUpdateGeneration = 0;
  private unregisterUpdateStartedCallback: (() => void) | undefined;

  constructor(
    public display: { updateStarted: NullarySignal; scheduleRedraw(): void },
    public position: Position,
    public velocity: CoordinateSpacePlaybackVelocity,
  ) {
    super();
    this.handleVelocityChanged();
    this.registerDisposer(
      velocity.changed.add(() => this.handleVelocityChanged()),
    );
  }

  disposed() {
    this.unregisterUpdateStartedCallback?.();
    super.disposed();
  }

  private handleVelocityChanged() {
    const { dimensionStates } = this;
    const ids = this.position.coordinateSpace.value?.ids ?? [];
    const rank = ids.length;
    const velocities = this.velocity.value;
    const generation = ++this.lastUpdateGeneration;
    const positionVector = this.position.value;
    const curTime = Date.now();
    for (let i = 0; i < rank; ++i) {
      const velocity = velocities[i];
      if (velocity === undefined) continue;
      if (velocity.velocity === 0 || velocity.paused) continue;
      const id = ids[i];
      const state = dimensionStates.get(id);
      if (state === undefined) {
        dimensionStates.set(id, {
          prevTime: curTime,
          dimensionIndex: i,
          prevCoordinate: positionVector[i],
          generation,
        });
      } else {
        state.generation = generation;
        state.dimensionIndex = i;
      }
    }
    for (const [id, state] of dimensionStates) {
      if (state.generation !== generation) {
        dimensionStates.delete(id);
      }
    }
    if (dimensionStates.size === 0) {
      const { unregisterUpdateStartedCallback } = this;
      if (unregisterUpdateStartedCallback !== undefined) {
        unregisterUpdateStartedCallback();
        this.unregisterUpdateStartedCallback = undefined;
      }
    } else {
      if (this.unregisterUpdateStartedCallback === undefined) {
        this.unregisterUpdateStartedCallback = this.display.updateStarted.add(
          () => this.updateStarted(),
        );
        this.display.scheduleRedraw();
      }
    }
  }

  private updateStarted() {
    const coordinateSpace = this.position.coordinateSpace.value;
    if (coordinateSpace === undefined) {
      return;
    }
    const ids = coordinateSpace.ids;
    const positionVector = this.position.value;
    let positionChanged = false;
    let velocityChanged = false;
    const curTime = Date.now();
    const velocities = this.velocity.value;
    const {
      bounds: { lowerBounds, upperBounds },
    } = coordinateSpace;
    for (const [id, dimensionState] of this.dimensionStates) {
      const { dimensionIndex } = dimensionState;
      if (ids[dimensionIndex] !== id) continue;
      const velocity = velocities[dimensionIndex];
      if (
        Math.floor(positionVector[dimensionIndex]) !==
        Math.floor(dimensionState.prevCoordinate)
      ) {
        // Pause this dimension.
        if (velocity?.paused === false) {
          velocities[dimensionIndex] = { ...velocity, paused: true };
          velocityChanged = true;
        }
        continue;
      }
      const timeDelta = curTime - dimensionState.prevTime;
      const velocityValue = velocity?.velocity ?? 0;
      const delta = (timeDelta * velocityValue) / 1000;
      if (delta === 0) continue;
      let newCoordinate = positionVector[dimensionIndex] + delta;
      const lowerBound = lowerBounds[dimensionIndex];
      const upperBound = Math.ceil(upperBounds[dimensionIndex] - 1);
      const limit = delta > 0 ? upperBound : lowerBound;
      const oppositeLimit = delta > 0 ? lowerBound : upperBound;
      const deltaSign = Math.sign(delta);
      if (
        Number.isFinite(limit) &&
        newCoordinate * deltaSign >= limit * deltaSign
      ) {
        switch (velocity!.atBoundary) {
          case VelocityBoundaryBehavior.LOOP:
            if (Number.isFinite(oppositeLimit)) {
              newCoordinate = oppositeLimit;
              break;
            }
          // fallthrough
          case VelocityBoundaryBehavior.STOP:
            velocities[dimensionIndex] = { ...velocity!, paused: true };
            velocityChanged = true;
            newCoordinate = limit;
            break;
          case VelocityBoundaryBehavior.REVERSE:
            velocities[dimensionIndex] = {
              ...velocity!,
              velocity: -velocityValue,
            };
            velocityChanged = true;
            newCoordinate = limit;
            break;
        }
      }
      positionVector[dimensionIndex] = newCoordinate;
      dimensionState.prevCoordinate = positionVector[dimensionIndex];
      dimensionState.prevTime = curTime;
      positionChanged = true;
    }
    if (positionChanged) {
      this.position.changed.dispatch();
    }
    if (velocityChanged) {
      this.velocity.changed.dispatch();
    }
    this.display.scheduleRedraw();
  }
}

type TrackableLinkInterface =
  | TrackableNavigationLink
  | TrackableNavigationSimpleLink;

function restoreLinkedFromJson(
  link: TrackableLinkInterface,
  value: { restoreState(obj: unknown): void },
  json: any,
) {
  if (json === undefined || Object.keys(json).length === 0) {
    link.value = NavigationLinkType.LINKED;
    return;
  }
  verifyObject(json);
  link.value = NavigationLinkType.UNLINKED;
  verifyObjectProperty(json, "value", (x) => {
    if (x !== undefined) {
      value.restoreState(x);
    }
  });
  verifyObjectProperty(json, "link", (x) => link.restoreState(x));
}

interface LinkableState<T> extends RefCounted, Trackable {
  assign(other: T): void;
}

abstract class LinkedBase<
  T extends LinkableState<T>,
  Link extends TrackableLinkInterface = TrackableNavigationLink,
> implements Trackable
{
  value: T;
  get changed() {
    return this.value.changed;
  }
  constructor(
    public peer: Owned<T>,
    public link: Link = new TrackableNavigationLink() as any,
  ) {}

  toJSON() {
    const { link } = this;
    if (link.value === NavigationLinkType.LINKED) {
      return undefined;
    }
    return { link: link.toJSON(), value: this.getValueJson() };
  }

  protected getValueJson(): any {
    return this.value.toJSON();
  }

  reset() {
    this.link.value = NavigationLinkType.LINKED;
  }

  restoreState(obj: any) {
    restoreLinkedFromJson(this.link, this.value, obj);
  }

  copyToPeer() {
    if (this.link.value !== NavigationLinkType.LINKED) {
      this.link.value = NavigationLinkType.UNLINKED;
      this.peer.assign(this.value);
      this.link.value = NavigationLinkType.LINKED;
    }
  }
}

abstract class SimpleLinkedBase<
    T extends RefCounted & Trackable & { assign(other: T): void },
  >
  extends LinkedBase<T, TrackableNavigationSimpleLink>
  implements Trackable {}

export class LinkedPosition extends LinkedBase<Position> {
  value = makeLinked(
    new Position(this.peer.coordinateSpace),
    this.peer,
    this.link,
    {
      assign: (a: Position, b: Position) => a.assign(b),
      isValid: (a: Position) => {
        return a.valid;
      },
      difference: Position.getOffset,
      add: Position.addOffset,
      subtract: (
        target: Position,
        source: Position,
        amount: Float32Array | undefined,
      ) => {
        Position.addOffset(target, source, amount, -1);
      },
    },
  );
}

function quaternionIsIdentity(q: quat) {
  return q[0] === 0 && q[1] === 0 && q[2] === 0 && q[3] === 1;
}

export class OrientationState extends RefCounted {
  orientation: quat;
  changed = new NullarySignal();

  constructor(orientation?: quat) {
    super();
    if (orientation == null) {
      orientation = quat.create();
    }
    this.orientation = orientation;
  }
  toJSON() {
    const { orientation } = this;
    quat.normalize(this.orientation, this.orientation);
    if (quaternionIsIdentity(orientation)) {
      return undefined;
    }
    return Array.prototype.slice.call(this.orientation);
  }
  restoreState(obj: any) {
    try {
      parseFiniteVec(this.orientation, obj);
      quat.normalize(this.orientation, this.orientation);
    } catch (ignoredError) {
      quat.identity(this.orientation);
    }
    this.changed.dispatch();
  }

  reset() {
    quat.identity(this.orientation);
    this.changed.dispatch();
  }

  snap() {
    const mat = mat3.create();
    mat3.fromQuat(mat, this.orientation);
    const usedAxes = [false, false, false];
    for (let i = 0; i < 3; ++i) {
      let maxComponent = 0;
      let argmaxComponent = 0;
      for (let j = 0; j < 3; ++j) {
        const value = mat[i * 3 + j];
        mat[i * 3 + j] = 0;
        if (usedAxes[j]) {
          continue;
        }
        if (Math.abs(value) > Math.abs(maxComponent)) {
          maxComponent = value;
          argmaxComponent = j;
        }
      }
      mat[i * 3 + argmaxComponent] = Math.sign(maxComponent);
      usedAxes[argmaxComponent] = true;
    }
    quat.fromMat3(this.orientation, mat);
    this.changed.dispatch();
  }

  /**
   * Returns a new OrientationState with orientation fixed to peerToSelf * peer.orientation.  Any
   * changes to the returned OrientationState will cause a corresponding change in peer, and vice
   * versa.
   */
  static makeRelative(peer: OrientationState, peerToSelf: quat) {
    const self = new OrientationState(
      quat.multiply(quat.create(), peer.orientation, peerToSelf),
    );
    let updatingPeer = false;
    self.registerDisposer(
      peer.changed.add(() => {
        if (!updatingPeer) {
          updatingSelf = true;
          quat.multiply(self.orientation, peer.orientation, peerToSelf);
          self.changed.dispatch();
          updatingSelf = false;
        }
      }),
    );
    let updatingSelf = false;
    const selfToPeer = quat.invert(quat.create(), peerToSelf);
    self.registerDisposer(
      self.changed.add(() => {
        if (!updatingSelf) {
          updatingPeer = true;
          quat.multiply(peer.orientation, self.orientation, selfToPeer);
          peer.changed.dispatch();
          updatingPeer = false;
        }
      }),
    );
    return self;
  }

  assign(other: Borrowed<OrientationState>) {
    quat.copy(this.orientation, other.orientation);
    this.changed.dispatch();
  }
}

export class LinkedOrientationState extends LinkedBase<OrientationState> {
  value = makeLinked(new OrientationState(), this.peer, this.link, {
    assign: (a: OrientationState, b: OrientationState) => a.assign(b),
    isValid: () => true,
    difference: (a: OrientationState, b: OrientationState) => {
      const temp = quat.create();
      return quat.multiply(
        temp,
        quat.invert(temp, b.orientation),
        a.orientation,
      );
    },
    add: (target: OrientationState, source: OrientationState, amount: quat) => {
      quat.multiply(target.orientation, source.orientation, amount);
      target.changed.dispatch();
    },
    subtract: (
      target: OrientationState,
      source: OrientationState,
      amount: quat,
    ) => {
      quat.multiply(
        target.orientation,
        source.orientation,
        quat.invert(tempQuat, amount),
      );
      target.changed.dispatch();
    },
  });
}

export interface RelativeDisplayScales {
  /**
   * Array of length `coordinateSpace.rank` specifying scale factors on top of (will be multiply by)
   * `coordinateSpace.scales` to use for display purposes.  This allows non-uniform zooming.
   */
  factors: Float64Array;
}

export class TrackableRelativeDisplayScales
  extends RefCounted
  implements Trackable, WatchableValueInterface<RelativeDisplayScales>
{
  changed = new NullarySignal();
  private curCoordinateSpace = emptyInvalidCoordinateSpace;
  private value_: RelativeDisplayScales = { factors: new Float64Array(0) };
  constructor(
    public coordinateSpace: WatchableValueInterface<CoordinateSpace>,
  ) {
    super();
    this.registerDisposer(coordinateSpace.changed.add(() => this.update()));
    this.update();
  }

  get value() {
    return this.update();
  }

  reset() {
    this.value_ = { factors: new Float64Array(0) };
    this.curCoordinateSpace = emptyInvalidCoordinateSpace;
    this.changed.dispatch();
  }

  toJSON() {
    const json: any = {};
    let nonEmpty = false;
    const { value } = this;
    const { factors } = value;
    const { names, rank } = this.curCoordinateSpace;
    for (let i = 0; i < rank; ++i) {
      const factor = factors[i];
      if (factor === 1) continue;
      json[names[i]] = factor;
      nonEmpty = true;
    }
    if (nonEmpty) return json;
    return undefined;
  }

  restoreState(json: unknown) {
    const {
      coordinateSpace: { value: coordinateSpace },
    } = this;
    const { names, rank } = coordinateSpace;
    const factors = new Float64Array(rank);
    factors.fill(-1);
    if (json !== undefined) {
      const obj = verifyObject(json);
      for (let i = 0; i < rank; ++i) {
        factors[i] = verifyObjectProperty(obj, names[i], (x) =>
          x === undefined ? 1 : verifyFinitePositiveFloat(x),
        );
      }
    }
    this.value_ = { factors };
    this.curCoordinateSpace = coordinateSpace;
    this.changed.dispatch();
  }

  setFactors(factors: Float64Array) {
    const {
      coordinateSpace: { value: coordinateSpace },
    } = this;
    if (factors.length !== coordinateSpace.rank) return;
    this.value_ = { factors };
    this.curCoordinateSpace = coordinateSpace;
    this.changed.dispatch();
  }

  private update() {
    const {
      coordinateSpace: { value: coordinateSpace },
    } = this;
    let value = this.value_;
    const { curCoordinateSpace } = this;
    if (curCoordinateSpace === coordinateSpace) return value;
    const { ids: oldDimensionIds } = curCoordinateSpace;
    const { ids: newDimensionIds, rank } = coordinateSpace;
    const oldFactors = value.factors;
    const newFactors = new Float64Array(rank);
    newFactors.fill(1);
    for (let i = 0; i < rank; ++i) {
      const id = newDimensionIds[i];
      const oldIndex = oldDimensionIds.indexOf(id);
      if (oldIndex === -1) continue;
      newFactors[i] = oldFactors[oldIndex];
    }
    if (arraysEqual(newFactors, oldFactors)) return value;
    value = this.value_ = { factors: newFactors };
    this.curCoordinateSpace = coordinateSpace;
    this.changed.dispatch();
    return value;
  }

  assign(other: TrackableRelativeDisplayScales) {
    this.setFactors(other.value.factors);
  }
}

function mapPerDimensionValues<
  T,
  A extends { length: number; [index: number]: T },
  C extends { new (n: number): A },
>(
  arrayConstructor: C,
  input: A,
  oldCoordinateSpace: CoordinateSpace,
  newCoordinateSpace: CoordinateSpace,
  defaultValue: (index: number) => T,
): A {
  if (oldCoordinateSpace === newCoordinateSpace) return input;
  const { ids: oldDimensionIds } = oldCoordinateSpace;
  const { rank: newRank, ids: newDimensionIds } = newCoordinateSpace;
  const output = new arrayConstructor(newRank);
  for (let newDim = 0; newDim < newRank; ++newDim) {
    const id = newDimensionIds[newDim];
    const oldDim = oldDimensionIds.indexOf(id);
    output[newDim] = oldDim === -1 ? defaultValue(newDim) : input[oldDim];
  }
  return output;
}

export class LinkedRelativeDisplayScales extends LinkedBase<TrackableRelativeDisplayScales> {
  value = makeLinked(
    new TrackableRelativeDisplayScales(this.peer.coordinateSpace),
    this.peer,
    this.link,
    {
      assign: (target, source) => target.assign(source),
      difference: (a, b) => {
        const { factors: fa } = a.value;
        const coordinateSpace = a.coordinateSpace.value;
        const fb = b.value.factors;
        return {
          coordinateSpace,
          offsets: vector.subtract(new Float64Array(fa.length), fa, fb),
        };
      },
      add: (
        target,
        source,
        delta: { offsets: Float64Array; coordinateSpace: CoordinateSpace },
      ) => {
        const newOffsets = mapPerDimensionValues(
          Float64Array,
          delta.offsets,
          delta.coordinateSpace,
          target.coordinateSpace.value,
          () => 0,
        );
        target.setFactors(
          vector.add(
            new Float64Array(newOffsets.length),
            newOffsets,
            source.value.factors,
          ),
        );
      },
      subtract: (
        target,
        source,
        delta: { offsets: Float64Array; coordinateSpace: CoordinateSpace },
      ) => {
        const newOffsets = mapPerDimensionValues(
          Float64Array,
          delta.offsets,
          delta.coordinateSpace,
          target.coordinateSpace.value,
          () => 0,
        );
        target.setFactors(
          vector.subtract(
            new Float64Array(newOffsets.length),
            source.value.factors,
            newOffsets,
          ),
        );
      },
      isValid: () => true,
    },
  );
}

export interface DisplayDimensionRenderInfo {
  /**
   * Number of global dimensions.
   */
  globalRank: number;

  /**
   * Array of length `globalRank` specifying global dimension names.
   */
  globalDimensionNames: readonly string[];

  /**
   * Number of displayed dimensions.  Must be <= 3.
   */
  displayRank: number;

  /**
   * Array of length 3.  The first `displayRank` elements specify the indices of the the global
   * dimensions that are displayed.  The remaining elements are `-1`.
   */
  displayDimensionIndices: Int32Array;

  /**
   * Array of length 3.  `voxelPhysicalScales[i]` equals
   * `relativeDisplayScales[d] * coordinateSpace.scales[d]`,
   * where `d = displayDimensionIndices[i]`, or `1` for `i >= rank`.
   */
  voxelPhysicalScales: Float64Array;

  /**
   * Unit corresponding to each dimension in `displayDimensionIndices`.  `displayDimensionUnits[i]`
   * is equal to `coordinateSpace.units[displayDimensionIndices[i]]`, or `''` if
   * `displayDimensionIndices[i] == -1`.
   */
  displayDimensionUnits: readonly string[];

  /**
   * Scale corresponding to each dimension in `displayDimensionIndices`.
   * `displayDimensionScales[i]` is equal to `coordinateSpace.scales[displayDimensionIndices[i]]`,
   * or `1` if `displayDimensionIndices[i] == -1`.
   */
  displayDimensionScales: Float64Array;

  /**
   * Physical scale corresponding to the canonical voxel.  Equal to minimum of
   * `voxelPhysicalScales.slice(0, rank)`, or `1` if `rank == 0`.
   */
  canonicalVoxelPhysicalSize: number;

  /**
   * Array of length 3.  Amount by which the voxel coordinates of each display dimensions must be
   * multiplied to convert to canonical voxels.  canonicalVoxelFactors[i] = voxelPhysicalScales[d] /
   * canonicalVoxelPhysicalSize, where d = dimensionIndices[i], or `1` for `i >= rank`.
   */
  canonicalVoxelFactors: Float64Array;
}

function getDisplayDimensionRenderInfo(
  coordinateSpace: CoordinateSpace,
  displayDimensions: DisplayDimensions,
  relativeDisplayScales: RelativeDisplayScales,
): DisplayDimensionRenderInfo {
  const {
    rank: globalRank,
    names: globalDimensionNames,
    units,
  } = coordinateSpace;
  const { displayRank, displayDimensionIndices } = displayDimensions;
  const canonicalVoxelFactors = new Float64Array(3);
  const voxelPhysicalScales = new Float64Array(3);
  let canonicalVoxelPhysicalSize: number;
  const { factors } = relativeDisplayScales;
  const displayDimensionUnits = new Array<string>(3);
  const displayDimensionScales = new Float64Array(3);
  canonicalVoxelFactors.fill(1);
  voxelPhysicalScales.fill(1);
  displayDimensionScales.fill(1);
  displayDimensionUnits.fill("");
  if (displayRank === 0) {
    canonicalVoxelPhysicalSize = 1;
  } else {
    canonicalVoxelPhysicalSize = Number.POSITIVE_INFINITY;
    const { scales } = coordinateSpace;
    for (let i = 0; i < displayRank; ++i) {
      const dim = displayDimensionIndices[i];
      const s = (voxelPhysicalScales[i] = factors[dim] * scales[dim]);
      canonicalVoxelPhysicalSize = Math.min(canonicalVoxelPhysicalSize, s);
      displayDimensionUnits[i] = units[dim];
      displayDimensionScales[i] = scales[dim];
    }
    for (let i = 0; i < displayRank; ++i) {
      canonicalVoxelFactors[i] =
        voxelPhysicalScales[i] / canonicalVoxelPhysicalSize;
    }
  }
  return {
    globalRank,
    globalDimensionNames,
    displayRank,
    displayDimensionIndices,
    displayDimensionUnits,
    displayDimensionScales,
    canonicalVoxelFactors,
    voxelPhysicalScales,
    canonicalVoxelPhysicalSize,
  };
}

export function displayDimensionRenderInfosEqual(
  a: DisplayDimensionRenderInfo,
  b: DisplayDimensionRenderInfo,
) {
  return (
    arraysEqual(a.globalDimensionNames, b.globalDimensionNames) &&
    arraysEqual(a.displayDimensionIndices, b.displayDimensionIndices) &&
    arraysEqual(a.canonicalVoxelFactors, b.canonicalVoxelFactors) &&
    arraysEqual(a.voxelPhysicalScales, b.voxelPhysicalScales) &&
    a.canonicalVoxelPhysicalSize === b.canonicalVoxelPhysicalSize &&
    arraysEqual(a.displayDimensionUnits, b.displayDimensionUnits) &&
    arraysEqual(a.displayDimensionScales, b.displayDimensionScales)
  );
}

export function validateDisplayDimensionRenderInfoProperty(
  obj: { displayDimensionRenderInfo: DisplayDimensionRenderInfo },
  expected: DisplayDimensionRenderInfo,
): boolean {
  const actual = obj.displayDimensionRenderInfo;
  if (actual === expected) return true;
  if (displayDimensionRenderInfosEqual(actual, expected)) {
    obj.displayDimensionRenderInfo = expected;
    return true;
  }
  return false;
}

export class WatchableDisplayDimensionRenderInfo extends RefCounted {
  changed = new NullarySignal();
  private curRelativeDisplayScales: RelativeDisplayScales =
    this.relativeDisplayScales.value;
  private curDisplayDimensions: DisplayDimensions =
    this.displayDimensions.value;
  private curCoordinateSpace: CoordinateSpace =
    this.relativeDisplayScales.coordinateSpace.value;
  private value_: DisplayDimensionRenderInfo = getDisplayDimensionRenderInfo(
    this.curCoordinateSpace,
    this.curDisplayDimensions,
    this.curRelativeDisplayScales,
  );
  get value() {
    const {
      relativeDisplayScales: {
        value: relativeDisplayScales,
        coordinateSpace: { value: coordinateSpace },
      },
      displayDimensions: { value: displayDimensions },
      curRelativeDisplayScales,
      curDisplayDimensions,
      curCoordinateSpace,
    } = this;
    let value = this.value_;
    if (
      curRelativeDisplayScales !== relativeDisplayScales ||
      curDisplayDimensions !== displayDimensions ||
      curCoordinateSpace !== coordinateSpace
    ) {
      this.curRelativeDisplayScales = relativeDisplayScales;
      this.curDisplayDimensions = displayDimensions;
      this.curCoordinateSpace = coordinateSpace;
      const newValue = getDisplayDimensionRenderInfo(
        coordinateSpace,
        displayDimensions,
        relativeDisplayScales,
      );
      if (!displayDimensionRenderInfosEqual(value, newValue)) {
        this.value_ = value = newValue;
        this.changed.dispatch();
      }
    }
    return value;
  }
  constructor(
    public relativeDisplayScales: Owned<TrackableRelativeDisplayScales>,
    public displayDimensions: Owned<TrackableDisplayDimensions>,
  ) {
    super();
    this.registerDisposer(relativeDisplayScales);
    this.registerDisposer(displayDimensions);
    const maybeUpdateValue = () => {
      this.value;
    };
    this.registerDisposer(relativeDisplayScales.changed.add(maybeUpdateValue));
    this.registerDisposer(displayDimensions.changed.add(maybeUpdateValue));
  }
}

export interface DisplayDimensions {
  coordinateSpace: CoordinateSpace;
  displayRank: number;
  displayDimensionIndices: Int32Array;
}

export class TrackableDisplayDimensions
  extends RefCounted
  implements Trackable
{
  changed = new NullarySignal();
  private default_ = true;
  private value_: DisplayDimensions | undefined = undefined;

  constructor(
    public coordinateSpace: WatchableValueInterface<CoordinateSpace>,
  ) {
    super();
    this.registerDisposer(
      this.coordinateSpace.changed.add(this.changed.dispatch),
    );
    this.update();
  }

  get value() {
    this.update();
    return this.value_!;
  }

  private update() {
    const {
      coordinateSpace: { value: coordinateSpace },
    } = this;
    const value = this.value_;
    if (value !== undefined && value.coordinateSpace === coordinateSpace) {
      return;
    }
    if (value === undefined || this.default_) {
      this.setToDefault(coordinateSpace);
      return;
    }
    const newDimensionIndices = new Int32Array(3);
    const { ids: oldDimensionIds } = value.coordinateSpace;
    const { ids: newDimensionIds } = coordinateSpace;
    const oldDimensionIndices = value.displayDimensionIndices;
    const oldRank = value.displayRank;
    let newRank = 0;
    for (let i = 0; i < oldRank; ++i) {
      const newDim = newDimensionIds.indexOf(
        oldDimensionIds[oldDimensionIndices[i]],
      );
      if (newDim === -1) continue;
      newDimensionIndices[newRank] = newDim;
      ++newRank;
    }
    newDimensionIndices.fill(-1, newRank);
    if (newRank === 0) {
      this.default_ = true;
      this.setToDefault(coordinateSpace);
      return;
    }
    this.assignValue(coordinateSpace, newRank, newDimensionIndices);
    this.changed.dispatch();
  }

  private setToDefault(coordinateSpace: CoordinateSpace) {
    const displayRank = Math.min(coordinateSpace.rank, 3);
    const displayDimensionIndices = new Int32Array(3);
    displayDimensionIndices.fill(-1);
    for (let i = 0; i < displayRank; ++i) {
      displayDimensionIndices[i] = i;
    }
    this.assignValue(coordinateSpace, displayRank, displayDimensionIndices);
  }

  private assignValue(
    coordinateSpace: CoordinateSpace,
    displayRank: number,
    displayDimensionIndices: Int32Array,
  ) {
    this.value_ = {
      coordinateSpace,
      displayRank,
      displayDimensionIndices,
    };
    this.changed.dispatch();
  }

  reset() {
    this.default_ = true;
    this.value_ = undefined;
    this.changed.dispatch();
  }

  restoreState(obj: any) {
    if (obj === undefined) {
      this.reset();
      return;
    }
    const displayDimensionNames = dimensionNamesFromJson(obj);
    if (displayDimensionNames.length > 3) {
      throw new Error("Number of spatial dimensions must be <= 3");
    }
    const {
      coordinateSpace: { value: coordinateSpace },
    } = this;
    const displayDimensionIndices = new Int32Array(3);
    displayDimensionIndices.fill(-1);
    const { names } = coordinateSpace;
    let displayRank = 0;
    for (const name of displayDimensionNames) {
      const index = names.indexOf(name);
      if (index === -1) continue;
      displayDimensionIndices[displayRank++] = index;
    }
    if (displayRank === 0) {
      this.reset();
      return;
    }
    this.default_ = false;
    this.assignValue(coordinateSpace, displayRank, displayDimensionIndices);
  }

  get default() {
    this.update();
    return this.default_;
  }

  set default(value: boolean) {
    if (this.default_ === value) return;
    if (value) {
      this.default_ = true;
      this.setToDefault(this.coordinateSpace.value);
    } else {
      this.default_ = false;
      this.changed.dispatch();
    }
  }

  setDimensionIndices(rank: number, dimensionIndices: Int32Array) {
    this.default_ = false;
    this.assignValue(this.coordinateSpace.value, rank, dimensionIndices);
  }

  toJSON() {
    if (this.default_) return undefined;
    const { value } = this;
    const displayDimensionNames: string[] = [];
    const {
      displayRank,
      displayDimensionIndices,
      coordinateSpace: { names },
    } = value;
    if (displayRank === 0) return undefined;
    for (let i = 0; i < displayRank; ++i) {
      displayDimensionNames[i] = names[displayDimensionIndices[i]];
    }
    return displayDimensionNames;
  }

  assign(other: TrackableDisplayDimensions) {
    if (other.default) {
      this.default = true;
    } else {
      const { displayRank, displayDimensionIndices } = other.value;
      this.setDimensionIndices(displayRank, displayDimensionIndices);
    }
  }
}

export class LinkedDisplayDimensions extends SimpleLinkedBase<TrackableDisplayDimensions> {
  value = makeSimpleLinked(
    new TrackableDisplayDimensions(this.peer.coordinateSpace),
    this.peer,
    this.link,
    {
      assign: (target, source) => target.assign(source),
      isValid: () => true,
    },
  );
}

export class DisplayPose extends RefCounted {
  changed = new NullarySignal();

  get displayDimensions(): Borrowed<TrackableDisplayDimensions> {
    return this.displayDimensionRenderInfo.displayDimensions;
  }

  get relativeDisplayScales(): Borrowed<TrackableRelativeDisplayScales> {
    return this.displayDimensionRenderInfo.relativeDisplayScales;
  }

  constructor(
    public position: Owned<Position>,
    public displayDimensionRenderInfo: WatchableDisplayDimensionRenderInfo,
    public orientation: Owned<OrientationState>,
  ) {
    super();
    this.registerDisposer(position);
    this.registerDisposer(orientation);
    this.registerDisposer(displayDimensionRenderInfo);
    this.registerDisposer(position.changed.add(this.changed.dispatch));
    this.registerDisposer(orientation.changed.add(this.changed.dispatch));
    this.registerDisposer(
      displayDimensionRenderInfo.changed.add(this.changed.dispatch),
    );
  }

  get valid() {
    return this.position.valid;
  }

  /**
   * Resets everything.
   */
  reset() {
    this.position.reset();
    this.orientation.reset();
    this.displayDimensions.reset();
  }

  updateDisplayPosition(
    fun: (pos: vec3) => boolean | void,
    temp: vec3 = tempVec3,
  ): boolean {
    const {
      coordinateSpace: { value: coordinateSpace },
      value: voxelCoordinates,
    } = this.position;
    const { displayDimensionIndices, displayRank } =
      this.displayDimensions.value;
    if (coordinateSpace === undefined) return false;
    temp.fill(0);
    for (let i = 0; i < displayRank; ++i) {
      const dim = displayDimensionIndices[i];
      temp[i] = voxelCoordinates[dim];
    }
    if (fun(temp) !== false) {
      for (let i = 0; i < displayRank; ++i) {
        const dim = displayDimensionIndices[i];
        voxelCoordinates[dim] = temp[i];
      }
      this.position.changed.dispatch();
      return true;
    }
    return false;
  }

  // Transform from view coordinates to global spatial coordinates.
  toMat4(mat: mat4, zoom: number) {
    mat4.fromQuat(mat, this.orientation.orientation);
    const { value: voxelCoordinates } = this.position;
    const { canonicalVoxelFactors, displayDimensionIndices } =
      this.displayDimensionRenderInfo.value;
    for (let i = 0; i < 3; ++i) {
      const dim = displayDimensionIndices[i];
      const scale = zoom / canonicalVoxelFactors[i];
      mat[i] *= scale;
      mat[4 + i] *= scale;
      mat[8 + i] *= scale;
      mat[12 + i] = voxelCoordinates[dim] || 0;
    }
  }

  toMat3(mat: mat3, zoom: number) {
    mat3.fromQuat(mat, this.orientation.orientation);
    const { canonicalVoxelFactors, displayRank } =
      this.displayDimensionRenderInfo.value;
    for (let i = 0; i < displayRank; ++i) {
      const scale = zoom / canonicalVoxelFactors[i];
      mat[i] *= scale;
      mat[3 + i] *= scale;
      mat[6 + i] *= scale;
    }
  }

  /**
   * Snaps the orientation to the nearest axis-aligned orientation, and
   * snaps the position to the nearest voxel.
   */
  snap() {
    this.orientation.snap();
    this.position.snapToVoxel();
    this.changed.dispatch();
  }

  translateDimensionRelative(dimensionIndex: number, adjustment: number) {
    if (!this.valid) {
      return;
    }
    const { position } = this;
    const { value: voxelCoordinates } = position;
    const { bounds } = position.coordinateSpace.value;
    voxelCoordinates[dimensionIndex] = clampAndRoundCoordinateToVoxelCenter(
      bounds,
      dimensionIndex,
      voxelCoordinates[dimensionIndex] + adjustment,
    );
    position.changed.dispatch();
  }

  translateVoxelsRelative(translation: vec3) {
    if (!this.valid) {
      return;
    }
    const temp = vec3.transformQuat(
      tempVec3,
      translation,
      this.orientation.orientation,
    );
    const { position } = this;
    const { value: voxelCoordinates } = position;
    const { displayDimensionIndices, displayRank } =
      this.displayDimensions.value;
    const { bounds } = position.coordinateSpace.value;
    for (let i = 0; i < displayRank; ++i) {
      const dim = displayDimensionIndices[i];
      const adjustment = temp[i];
      if (adjustment === 0) continue;
      voxelCoordinates[dim] = clampAndRoundCoordinateToVoxelCenter(
        bounds,
        dim,
        voxelCoordinates[dim] + adjustment,
      );
    }
    this.position.changed.dispatch();
  }

  rotateRelative(axis: vec3, angle: number) {
    const temp = quat.create();
    quat.setAxisAngle(temp, axis, angle);
    const orientation = this.orientation.orientation;
    quat.multiply(orientation, orientation, temp);
    this.orientation.changed.dispatch();
  }

  rotateAbsolute(axis: vec3, angle: number, fixedPoint: Float32Array) {
    const {
      coordinateSpace: { value: coordinateSpace },
      value: voxelCoordinates,
    } = this.position;
    if (coordinateSpace === undefined) return;
    const {
      relativeDisplayScales: {
        value: { factors: relativeDisplayScales },
      },
      displayDimensions: {
        value: { displayDimensionIndices, displayRank },
      },
    } = this;
    const { scales } = coordinateSpace;
    const temp = quat.create();
    quat.setAxisAngle(temp, axis, angle);
    const orientation = this.orientation.orientation;

    // We want the coordinates in the transformed coordinate frame of the fixed point to remain
    // the same after the rotation.

    // We have the invariants:
    // oldOrienation * fixedPointLocal + oldPosition == fixedPoint.
    // newOrientation * fixedPointLocal + newPosition == fixedPoint.

    // Therefore, we compute fixedPointLocal by:
    // fixedPointLocal == inverse(oldOrientation) * (fixedPoint - oldPosition).
    const fixedPointLocal = tempVec3;
    tempVec3.fill(0);
    for (let i = 0; i < displayRank; ++i) {
      const dim = displayDimensionIndices[i];
      const diff = fixedPoint[dim] - voxelCoordinates[dim];
      fixedPointLocal[i] = diff * scales[dim] * relativeDisplayScales[dim];
    }
    const invOrientation = quat.invert(tempQuat, orientation);
    vec3.transformQuat(fixedPointLocal, fixedPointLocal, invOrientation);

    // We then compute the newPosition by:
    // newPosition := fixedPoint - newOrientation * fixedPointLocal.
    quat.multiply(orientation, temp, orientation);
    vec3.transformQuat(fixedPointLocal, fixedPointLocal, orientation);

    for (let i = 0; i < displayRank; ++i) {
      const dim = displayDimensionIndices[i];
      voxelCoordinates[dim] =
        fixedPoint[dim] -
        fixedPointLocal[i] / (scales[dim] * relativeDisplayScales[dim]);
    }
    this.position.changed.dispatch();
    this.orientation.changed.dispatch();
  }

  translateNonDisplayDimension(
    nonSpatialDimensionIndex: number,
    adjustment: number,
  ) {
    if (!this.valid) return;
    const { displayDimensionIndices } = this.displayDimensions.value;
    const { position } = this;
    const rank = position.coordinateSpace.value.rank;
    for (let i = 0; i < rank; ++i) {
      if (displayDimensionIndices.indexOf(i) !== -1) continue;
      if (nonSpatialDimensionIndex-- === 0) {
        this.translateDimensionRelative(i, adjustment);
        return;
      }
    }
  }
}

export type TrackableZoomInterface =
  | TrackableProjectionZoom
  | TrackableCrossSectionZoom;

export class LinkedZoomState<
  T extends TrackableProjectionZoom | TrackableCrossSectionZoom,
> extends LinkedBase<T> {
  constructor(
    peer: Owned<T>,
    displayDimensionRenderInfo: Owned<WatchableDisplayDimensionRenderInfo>,
  ) {
    super(peer);
    this.value = (() => {
      const self: T = new (peer.constructor as any)(displayDimensionRenderInfo);
      const assign = (target: T, source: T) => {
        target.assign(source);
      };
      const difference = (a: T, b: T) => {
        return (
          (a.value / b.value) *
          (a.canonicalVoxelPhysicalSize / b.canonicalVoxelPhysicalSize)
        );
      };
      const add = (target: T, source: T, amount: number) => {
        target.setPhysicalScale(
          source.value * amount,
          source.canonicalVoxelPhysicalSize,
        );
      };
      const subtract = (target: T, source: T, amount: number) => {
        target.setPhysicalScale(
          source.value / amount,
          source.canonicalVoxelPhysicalSize,
        );
      };
      const isValid = (x: T) =>
        x.coordinateSpaceValue.valid && x.canonicalVoxelPhysicalSize !== 0;
      makeLinked(
        self as RefCounted & { changed: NullarySignal },
        this.peer,
        this.link,
        {
          assign,
          isValid,
          difference,
          add,
          subtract,
        },
      );
      return self;
    })();
  }
}

export function linkedStateLegacyJsonView<
  T extends LinkableState<T> & { readonly legacyJsonView: Trackable },
>(linked: LinkedBase<T>) {
  return {
    changed: linked.changed,
    toJSON() {
      return linked.toJSON();
    },
    restoreState(obj: unknown) {
      restoreLinkedFromJson(linked.link, linked.value.legacyJsonView, obj);
    },
    reset() {
      linked.reset();
    },
  };
}

abstract class TrackableZoom
  extends RefCounted
  implements Trackable, WatchableValueInterface<number>
{
  readonly changed = new NullarySignal();
  private curCanonicalVoxelPhysicalSize = 0;
  private value_: number = Number.NaN;
  protected legacyValue_: number = Number.NaN;

  /**
   * Zoom factor.  For cross section views, in canonical voxels per viewport pixel.  For projection
   * views, in canonical voxels per viewport height (for orthographic projection).
   */
  get value() {
    this.handleCoordinateSpaceChanged();
    return this.value_;
  }

  set value(value: number) {
    const { canonicalVoxelPhysicalSize } = this;
    if (
      Object.is(value, this.value_) &&
      canonicalVoxelPhysicalSize === this.curCanonicalVoxelPhysicalSize
    ) {
      return;
    }
    this.curCanonicalVoxelPhysicalSize = canonicalVoxelPhysicalSize;
    this.legacyValue_ = Number.NaN;
    this.value_ = value;
    this.changed.dispatch();
  }

  get canonicalVoxelPhysicalSize() {
    return this.displayDimensionRenderInfo.value.canonicalVoxelPhysicalSize;
  }

  get coordinateSpaceValue() {
    return this.displayDimensionRenderInfo.relativeDisplayScales.coordinateSpace
      .value;
  }

  /**
   * Sets the zoom factor in the legacy units.  For cross section views, `1e-9` spatial units per
   * viewport pixel.  For projection views, `2 * 100 * Math.tan(Math.PI / 8) * 1e-9` spatial units
   * per viewport height (for orthographic projection).
   */
  set legacyValue(value: number) {
    if (Object.is(value, this.legacyValue_)) return;
    this.value_ = Number.NaN;
    this.legacyValue_ = value;
    this.curCanonicalVoxelPhysicalSize = 0;
    this.changed.dispatch();
  }

  get legacyValue() {
    return this.legacyValue_;
  }

  constructor(
    public displayDimensionRenderInfo: Owned<WatchableDisplayDimensionRenderInfo>,
  ) {
    super();
    this.registerDisposer(displayDimensionRenderInfo);
    this.registerDisposer(
      displayDimensionRenderInfo.changed.add(() =>
        this.handleCoordinateSpaceChanged(),
      ),
    );
    this.registerDisposer(
      displayDimensionRenderInfo.relativeDisplayScales.coordinateSpace.changed.add(
        () => this.handleCoordinateSpaceChanged(),
      ),
    );
    this.handleCoordinateSpaceChanged();
  }

  handleCoordinateSpaceChanged() {
    const { value_ } = this;
    const {
      displayDimensionRenderInfo: {
        value: { canonicalVoxelPhysicalSize },
        relativeDisplayScales: {
          coordinateSpace: { value: coordinateSpace },
        },
      },
    } = this;
    const { curCanonicalVoxelPhysicalSize } = this;
    if (
      !Number.isNaN(value_) &&
      canonicalVoxelPhysicalSize === curCanonicalVoxelPhysicalSize
    ) {
      return;
    }
    if (!Number.isNaN(value_)) {
      if (curCanonicalVoxelPhysicalSize !== 0) {
        this.value_ =
          value_ * (curCanonicalVoxelPhysicalSize / canonicalVoxelPhysicalSize);
        this.curCanonicalVoxelPhysicalSize = canonicalVoxelPhysicalSize;
        this.changed.dispatch();
      }
      return;
    }
    if (!coordinateSpace.valid || canonicalVoxelPhysicalSize === 0) {
      return;
    }
    this.curCanonicalVoxelPhysicalSize = canonicalVoxelPhysicalSize;
    this.value_ = this.getDefaultValue();
    this.changed.dispatch();
  }

  protected abstract getDefaultValue(): number;

  toJSON() {
    const { value } = this;
    return Number.isNaN(value) ? undefined : value;
  }

  restoreState(obj: any) {
    this.curCanonicalVoxelPhysicalSize = 0;
    this.legacyValue_ = Number.NaN;
    if (obj === undefined) {
      this.value_ = Number.NaN;
    } else {
      this.value_ = verifyFinitePositiveFloat(obj);
    }
    this.changed.dispatch();
  }

  reset() {
    this.curCanonicalVoxelPhysicalSize = 0;
    this.value_ = Number.NaN;
    this.legacyValue_ = Number.NaN;
    this.changed.dispatch();
  }

  get legacyJsonView() {
    const self = this;
    return {
      changed: self.changed,
      toJSON() {
        return self.toJSON();
      },
      reset() {
        return self.reset();
      },
      restoreState(obj: any) {
        self.legacyValue = verifyFinitePositiveFloat(obj);
      },
    };
  }

  setPhysicalScale(
    scaleInCanonicalVoxels: number,
    canonicalVoxelPhysicalSize: number,
  ) {
    const curCanonicalVoxelPhysicalSize = (this.curCanonicalVoxelPhysicalSize =
      this.canonicalVoxelPhysicalSize);
    this.value =
      scaleInCanonicalVoxels *
      (canonicalVoxelPhysicalSize / curCanonicalVoxelPhysicalSize);
  }

  assign(source: TrackableZoomInterface) {
    const { legacyValue } = source;
    if (!Number.isNaN(legacyValue)) {
      this.legacyValue = legacyValue;
    } else {
      this.setPhysicalScale(source.value, source.canonicalVoxelPhysicalSize);
    }
  }
}

export class TrackableCrossSectionZoom extends TrackableZoom {
  protected getDefaultValue() {
    const { legacyValue_ } = this;
    if (Number.isNaN(legacyValue_)) {
      // Default is 1 voxel per viewport pixel.
      return 1;
    }
    const { canonicalVoxelPhysicalSize } = this;
    return (this.legacyValue_ * 1e-9) / canonicalVoxelPhysicalSize;
  }
}

export class TrackableProjectionZoom extends TrackableZoom {
  protected getDefaultValue() {
    const { legacyValue_ } = this;
    if (!Number.isNaN(legacyValue_)) {
      this.legacyValue_ = Number.NaN;
      const { canonicalVoxelPhysicalSize } = this;
      return (
        (2 * 100 * Math.tan(Math.PI / 8) * 1e-9 * legacyValue_) /
        canonicalVoxelPhysicalSize
      );
    }
    const {
      coordinateSpaceValue: {
        bounds: { lowerBounds, upperBounds },
      },
    } = this;
    const { canonicalVoxelFactors, displayDimensionIndices } =
      this.displayDimensionRenderInfo.value;
    let value = canonicalVoxelFactors.reduce((x, factor, i) => {
      const dim = displayDimensionIndices[i];
      const extent = (upperBounds[dim] - lowerBounds[dim]) * factor;
      return Math.max(x, extent);
    }, 0);
    if (!Number.isFinite(value)) {
      // Default to showing 1024 voxels if there is no bounds information.
      value = 1024;
    } else {
      value = 2 ** Math.ceil(Math.log2(value));
    }
    return value;
  }
}

export class TrackableDepthRange
  extends RefCounted
  implements WatchableValueInterface<number>
{
  changed = new NullarySignal();

  constructor(
    public readonly defaultValue: number,
    public displayDimensionRenderInfo: WatchableValueInterface<DisplayDimensionRenderInfo>,
  ) {
    super();
    this.value_ = defaultValue;
    this.canonicalVoxelPhysicalSize =
      displayDimensionRenderInfo.value.canonicalVoxelPhysicalSize;
    this.registerDisposer(
      displayDimensionRenderInfo.changed.add(() => {
        this.value;
      }),
    );
  }

  private value_: number;
  canonicalVoxelPhysicalSize: number;

  get value() {
    let { value_ } = this;
    if (value_ > 0) {
      const { canonicalVoxelPhysicalSize } =
        this.displayDimensionRenderInfo.value;
      const prevCanonicalVoxelPhysicalSize = this.canonicalVoxelPhysicalSize;
      if (canonicalVoxelPhysicalSize !== prevCanonicalVoxelPhysicalSize) {
        this.canonicalVoxelPhysicalSize = canonicalVoxelPhysicalSize;
        value_ =
          this.value_ =
          value_ =
            prevCanonicalVoxelPhysicalSize / canonicalVoxelPhysicalSize;
        this.changed.dispatch();
      }
    }
    return value_;
  }

  set value(value: number) {
    if (value === this.value) return;
    this.value_ = value;
    const { canonicalVoxelPhysicalSize } =
      this.displayDimensionRenderInfo.value;
    this.canonicalVoxelPhysicalSize = canonicalVoxelPhysicalSize;
    this.changed.dispatch();
  }

  toJSON() {
    const { value } = this;
    if (value === this.defaultValue) return undefined;
    return value;
  }

  reset() {
    this.value = this.defaultValue;
  }

  restoreState(obj: unknown) {
    if (typeof obj !== "number" || !Number.isFinite(obj) || obj === 0) {
      this.value = this.defaultValue;
    } else {
      this.value = obj;
    }
  }

  setValueAbsolute(value: number, sourceCanonicalVoxelPhysicalSize: number) {
    if (value > 0) {
      const { canonicalVoxelPhysicalSize } =
        this.displayDimensionRenderInfo.value;
      value =
        value * (sourceCanonicalVoxelPhysicalSize / canonicalVoxelPhysicalSize);
    }
    this.value = value;
  }

  assign(other: TrackableDepthRange) {
    this.setValueAbsolute(other.value, other.canonicalVoxelPhysicalSize);
  }
}

export class LinkedDepthRange extends SimpleLinkedBase<TrackableDepthRange> {
  constructor(
    peer: Owned<TrackableDepthRange>,
    displayDimensionRenderInfo: WatchableValueInterface<DisplayDimensionRenderInfo>,
  ) {
    super(peer);
    this.value = makeSimpleLinked(
      new TrackableDepthRange(peer.defaultValue, displayDimensionRenderInfo),
      this.peer,
      this.link,
      {
        assign: (target, source) => target.assign(source),
        isValid: () => true,
      },
    );
  }
}

export class NavigationState<
  Zoom extends TrackableZoomInterface = TrackableZoomInterface,
> extends RefCounted {
  changed = new NullarySignal();

  constructor(
    public pose: Owned<DisplayPose>,
    public zoomFactor: Owned<Zoom>,
    public depthRange: Owned<TrackableDepthRange>,
  ) {
    super();
    this.registerDisposer(pose);
    this.registerDisposer(zoomFactor);
    this.registerDisposer(depthRange);
    this.registerDisposer(this.pose.changed.add(this.changed.dispatch));
    this.registerDisposer(this.zoomFactor.changed.add(this.changed.dispatch));
    this.registerDisposer(this.depthRange.changed.add(this.changed.dispatch));
  }
  get coordinateSpace() {
    return this.pose.position.coordinateSpace;
  }

  /**
   * Resets everything.
   */
  reset() {
    this.pose.reset();
    this.zoomFactor.reset();
  }

  get position() {
    return this.pose.position;
  }
  get displayDimensions() {
    return this.pose.displayDimensions;
  }
  get relativeDisplayScales() {
    return this.pose.relativeDisplayScales;
  }
  get displayDimensionRenderInfo() {
    return this.pose.displayDimensionRenderInfo;
  }
  toMat4(mat: mat4) {
    this.pose.toMat4(mat, this.zoomFactor.value);
  }
  toMat3(mat: mat3) {
    this.pose.toMat3(mat, this.zoomFactor.value);
  }

  get relativeDepthRange() {
    let depthRange = this.depthRange.value;
    if (depthRange > 0) {
      depthRange /= this.zoomFactor.value;
    } else {
      depthRange *= -1;
    }
    return depthRange;
  }

  get valid() {
    return this.pose.valid && !Number.isNaN(this.zoomFactor.value);
  }

  zoomBy(factor: number) {
    this.zoomFactor.value *= factor;
  }
}
